fix: sync MCP sessions to Redis for multi-replica deployments - #3491
Conversation
📝 WalkthroughWalkthroughAdds Redis-backed MCP session persistence and recovery: a new servlet filter, DTO, and Spring bean; Redisson dependency promoted to runtime; and an integration test that verifies session recovery after local eviction. Changes
Sequence DiagramsequenceDiagram
participant Client as MCP Client
participant Filter as McpSessionRedisFilter
participant Local as Local Sessions Map
participant Redis as Redis
participant Factory as Session Factory
rect rgba(100,150,200,0.5)
Note over Client,Filter: Incoming request to /mcp/* with MCP-Session-Id
Client->>Filter: HTTP request (MCP-Session-Id)
Filter->>Local: check session map for id
Local-->>Filter: session missing
Filter->>Redis: GET mcp_session:<id>
Redis-->>Filter: session data (JSON)
Filter->>Local: restore session into sessions map
Filter->>Factory: forward request to session factory
end
rect rgba(150,200,100,0.5)
Note over Factory,Filter: Request handling may create new session
Factory-->>Filter: response with MCP-Session-Id header
Filter->>Redis: SET mcp_session:<id> (data) EX 172800
Redis-->>Filter: ACK
Filter-->>Client: HTTP response
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
3f51675 to
cd00393
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt (1)
65-94: Good integration test covering the core recovery flow.The test cleanly validates the end-to-end scenario: session creation → Redis persistence → local eviction → recovery → successful tool call. The use of reflection to access the sessions map mirrors the filter's approach, keeping the test realistic.
One minor gap: the test doesn't assert that the recovered session's capabilities/client info match the original. If the serialization/deserialization round-trip has a bug, the session would exist in the map but with null/wrong metadata.
Optional: verify session data fidelity
// After step 5, verify the recovered session has correct data val recoveredSession = sessionsMap[sessionId]!! // Use the same reflection approach as the filter to check clientInfo is non-null val clientInfoField = McpStreamableServerSession::class.java.getDeclaredField("clientInfo") clientInfoField.isAccessible = true val clientInfo = (clientInfoField.get(recoveredSession) as? java.util.concurrent.atomic.AtomicReference<*>)?.get() assertThat(clientInfo).isNotNull🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt` around lines 65 - 94, Update the test method session is recovered from Redis after local eviction to also verify the recovered session's metadata: after step 5 retrieve the recoveredSession from sessionsMap using sessionId, use reflection on McpStreamableServerSession to access the private clientInfo field (make it accessible), unwrap the AtomicReference to get the underlying clientInfo object and assert it is not null so the serialization/deserialization preserved client info; reference symbols: McpRedisSessionRecoveryTest, sessionsMap, sessionId, McpStreamableServerSession, clientInfo.backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt (2)
87-118: Recovered session lacks SSE transport — subsequent streaming may fail.The
McpStreamableServerSessionconstructed here hasclientCapabilities,clientInfo,requestTimeout, and handlers — but it does not restore the session's SSE transport or any active subscriptions. If the MCP protocol requires server-to-client push (e.g., notifications, progress updates) on a recovered session, those will silently fail because no transport is connected.This is likely acceptable for request/response tool calls (which is what the test covers), but worth documenting as a known limitation so that future developers don't assume full session fidelity after recovery.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt` around lines 87 - 118, The recovered session created in recoverSessionFromRedis using McpStreamableServerSession lacks any SSE transport or subscription state, so server-to-client streaming/notifications will silently fail; update recoverSessionFromRedis to restore the SSE transport and any active subscriptions from McpSessionData if those fields exist (rehydrate transport and subscription lists into the new McpStreamableServerSession and reattach into sessionsMap), and if no transport/subscriptions are stored, explicitly mark the session as non-streamable and log a clear warning so callers know streaming is unavailable; reference McpStreamableServerSession, McpSessionData, recoverSessionFromRedis and sessionsMap when making the changes.
226-230: Consider makingREDIS_KEY_PREFIXconfigurable or include a version discriminator.If the SDK constructor signature changes across versions, stale Redis entries written by an older version could be deserialized into an incompatible session on a newer replica. Including a schema version in the key prefix (e.g.,
mcp_session:v1:) would let you invalidate old entries on upgrade without manual Redis flushes.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt` around lines 226 - 230, REDIS_KEY_PREFIX is currently hardcoded in the McpSessionRedisFilter companion object; make the Redis key prefix configurable or include a schema/version discriminator (e.g., "mcp_session:v1:") so old entries can be invalidated on upgrades. Change REDIS_KEY_PREFIX to be injected/read from configuration (or build it from an application property like mcp.session.redisPrefix with default "mcp_session:v1:") and update all places that reference REDIS_KEY_PREFIX in McpSessionRedisFilter to use the new configurable value; ensure the default includes the version token and document the property for future version bumps.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt`:
- Around line 69-84: The pre-handle check in McpSessionRedisFilter uses
sessionsMap.containsKey(sessionId) then calls
recoverSessionFromRedis(sessionId), which can cause concurrent threads to
rebuild the same McpStreamableServerSession and overwrite transport/SSE state;
modify recoverSessionFromRedis (or the call site) to avoid duplicate
construction by using a concurrent insertion: attempt to insert the recovered
session into sessionsMap with computeIfAbsent or use putIfAbsent after
constructing the McpStreamableServerSession and discard/close the newly created
one if another thread won the race, ensuring only the winner's transport/SSE is
kept; keep the MCP_SESSION_ID_HEADER lookup and response capture logic
unchanged.
- Around line 125-130: The cast of clientCapabilities/clientInfo to
java.util.concurrent.atomic.AtomicReference in McpSessionRedisFilter (via
getPrivateField) is fragile and can silently yield null; change the logic to
detect when the retrieved field is not an AtomicReference: if it is an
AtomicReference, use .get() as before; otherwise attempt to use the field value
directly as the capabilities/info and emit a warning via the class logger
(include field name and actual field type) so you surface SDK changes; keep the
existing behavior when values end up null but ensure the warning is logged to
aid debugging (refer to clientCapabilities, clientInfo, capabilitiesValue,
infoValue, getPrivateField).
- Around line 103-112: Add a startup-time smoke check that attempts to
instantiate McpStreamableServerSession to fail fast if the SDK constructor
signature changes: in the McpSessionRedisFilter (or a dedicated `@Component`) add
a `@PostConstruct` or ApplicationReadyEvent handler which constructs a
minimal/dummy McpStreamableServerSession using the same positional args used in
your deserialization path (sessionId, clientCapabilities, clientInfo,
factoryFields.requestTimeout, factoryFields.requestHandlers,
factoryFields.notificationHandlers) and log or rethrow any exception; this uses
the same symbols (McpStreamableServerSession, McpSessionRedisFilter,
factoryFields) as the recovery flow and complements McpRedisSessionRecoveryTest
by validating constructor compatibility at startup.
In `@backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt`:
- Around line 60-63: Add an `@AfterEach` cleanup that deletes the test data
created in setup(): call the inverse cleanup for createTestDataWithPat() (e.g.,
deleteTestData or removeTestData) inside a method annotated with `@AfterEach` in
McpRedisSessionRecoveryTest so each test method's data is removed; ensure the
cleanup method references the same test data holder (the variable data)
initialized in setup() and runs after each test to avoid accumulation between
tests.
---
Nitpick comments:
In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt`:
- Around line 87-118: The recovered session created in recoverSessionFromRedis
using McpStreamableServerSession lacks any SSE transport or subscription state,
so server-to-client streaming/notifications will silently fail; update
recoverSessionFromRedis to restore the SSE transport and any active
subscriptions from McpSessionData if those fields exist (rehydrate transport and
subscription lists into the new McpStreamableServerSession and reattach into
sessionsMap), and if no transport/subscriptions are stored, explicitly mark the
session as non-streamable and log a clear warning so callers know streaming is
unavailable; reference McpStreamableServerSession, McpSessionData,
recoverSessionFromRedis and sessionsMap when making the changes.
- Around line 226-230: REDIS_KEY_PREFIX is currently hardcoded in the
McpSessionRedisFilter companion object; make the Redis key prefix configurable
or include a schema/version discriminator (e.g., "mcp_session:v1:") so old
entries can be invalidated on upgrades. Change REDIS_KEY_PREFIX to be
injected/read from configuration (or build it from an application property like
mcp.session.redisPrefix with default "mcp_session:v1:") and update all places
that reference REDIS_KEY_PREFIX in McpSessionRedisFilter to use the new
configurable value; ensure the default includes the version token and document
the property for future version bumps.
In `@backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt`:
- Around line 65-94: Update the test method session is recovered from Redis
after local eviction to also verify the recovered session's metadata: after step
5 retrieve the recoveredSession from sessionsMap using sessionId, use reflection
on McpStreamableServerSession to access the private clientInfo field (make it
accessible), unwrap the AtomicReference to get the underlying clientInfo object
and assert it is not null so the serialization/deserialization preserved client
info; reference symbols: McpRedisSessionRecoveryTest, sessionsMap, sessionId,
McpStreamableServerSession, clientInfo.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
backend/app/build.gradlebackend/app/src/main/kotlin/io/tolgee/mcp/McpConfig.ktbackend/app/src/main/kotlin/io/tolgee/mcp/McpSessionData.ktbackend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.ktbackend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt
7282bfa to
b9666ad
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt (1)
72-74:⚠️ Potential issue | 🟡 MinorAvoid non-atomic recover/insert for the same session ID.
Line 72 + Line 114 allow concurrent requests to reconstruct and overwrite the same session. Use atomic insertion (
putIfAbsent/computeIfAbsent) to prevent duplicate construction and overwrite races.💡 Suggested concurrency-safe adjustment
- sessionsMap[sessionId] = session + sessionsMap.putIfAbsent(sessionId, session)- if (sessionId != null && !sessionsMap.containsKey(sessionId)) { + if (sessionId != null && !sessionsMap.containsKey(sessionId)) { recoverSessionFromRedis(sessionId) }(Keep the pre-check, but ensure final insertion is atomic.)
Also applies to: 114-114
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt` around lines 72 - 74, The current non-atomic pre-check + recover flow can lead to duplicate/overwriting sessions for the same sessionId; update the insertion into sessionsMap to be atomic (use sessionsMap.putIfAbsent(sessionId, session) or sessionsMap.computeIfAbsent(sessionId, id -> recoveredSession)) in the recoverSessionFromRedis flow so concurrent threads cannot reconstruct and overwrite the same session; keep the existing pre-check (if (sessionId != null && !sessionsMap.containsKey(sessionId)) ...) but ensure the final store uses an atomic method in McpSessionRedisFilter where sessionId, sessionsMap and recoverSessionFromRedis are used.backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt (1)
60-63:⚠️ Potential issue | 🟡 MinorAdd per-test cleanup for created TestData.
This test creates persisted data in
@BeforeEachbut does not clean it in@AfterEach. Please add cleanup fordata.testDatato keep isolation between test methods and avoid data accumulation.As per coding guidelines "Use TestData classes for test setup following the pattern: create TestData instance, save with testDataService, and clean in
@AfterEach".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt` around lines 60 - 63, The setup() method calls createTestDataWithPat() and stores the result in data but there's no per-test cleanup; add an `@AfterEach` method that calls testDataService.delete(data.testData) (or the TestData cleanup helper used elsewhere) to remove the persisted TestData after each test, ensuring you reference the same data variable produced by createTestDataWithPat() and import/annotate the cleanup method with `@AfterEach` so test isolation is preserved.
🧹 Nitpick comments (1)
backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt (1)
218-220: Use case-insensitive header name matching in wrapper.Line 218 and Line 228 compare header names case-sensitively. Use
equals(..., ignoreCase = true)so alternate casing still capturesMcp-Session-Id.Also applies to: 228-230
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt` around lines 218 - 220, In the header-wrapping logic inside McpSessionRedisFilter (when comparing incoming header names to MCP_SESSION_ID_HEADER) change the case-sensitive comparisons that use == to use equals(..., ignoreCase = true) so alternate header casing (e.g., "Mcp-Session-Id") is captured; update both places where name == MCP_SESSION_ID_HEADER (the block that sets capturedSessionId and the later comparison around lines referencing capturedSessionId) to use MCP_SESSION_ID_HEADER.equals(name, ignoreCase = true).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt`:
- Around line 71-74: Wrap the reflective local-map lookup around
sessionsMap.containsKey(sessionId) in a safe try/catch inside
McpSessionRedisFilter pre-handle so reflective failures don't throw: call
sessionsMap.containsKey(sessionId) inside a small try block and treat any caught
Exception as "unknown / false" (i.e., skip recovery) so that
recoverSessionFromRedis(sessionId) is only invoked when the containsKey check
safely returns false; reference the sessionsMap field and
recoverSessionFromRedis method in the change.
---
Duplicate comments:
In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt`:
- Around line 72-74: The current non-atomic pre-check + recover flow can lead to
duplicate/overwriting sessions for the same sessionId; update the insertion into
sessionsMap to be atomic (use sessionsMap.putIfAbsent(sessionId, session) or
sessionsMap.computeIfAbsent(sessionId, id -> recoveredSession)) in the
recoverSessionFromRedis flow so concurrent threads cannot reconstruct and
overwrite the same session; keep the existing pre-check (if (sessionId != null
&& !sessionsMap.containsKey(sessionId)) ...) but ensure the final store uses an
atomic method in McpSessionRedisFilter where sessionId, sessionsMap and
recoverSessionFromRedis are used.
In `@backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt`:
- Around line 60-63: The setup() method calls createTestDataWithPat() and stores
the result in data but there's no per-test cleanup; add an `@AfterEach` method
that calls testDataService.delete(data.testData) (or the TestData cleanup helper
used elsewhere) to remove the persisted TestData after each test, ensuring you
reference the same data variable produced by createTestDataWithPat() and
import/annotate the cleanup method with `@AfterEach` so test isolation is
preserved.
---
Nitpick comments:
In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt`:
- Around line 218-220: In the header-wrapping logic inside McpSessionRedisFilter
(when comparing incoming header names to MCP_SESSION_ID_HEADER) change the
case-sensitive comparisons that use == to use equals(..., ignoreCase = true) so
alternate header casing (e.g., "Mcp-Session-Id") is captured; update both places
where name == MCP_SESSION_ID_HEADER (the block that sets capturedSessionId and
the later comparison around lines referencing capturedSessionId) to use
MCP_SESSION_ID_HEADER.equals(name, ignoreCase = true).
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
backend/app/build.gradlebackend/app/src/main/kotlin/io/tolgee/mcp/McpConfig.ktbackend/app/src/main/kotlin/io/tolgee/mcp/McpSessionData.ktbackend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.ktbackend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt
WebMvcStreamableServerTransportProvider stores sessions in an in-memory ConcurrentHashMap, causing 404 "Session not found" errors when requests scatter across replicas. Add a servlet filter that persists session metadata to Redis and reconstructs sessions on replicas that haven't seen the client. Tracked upstream: modelcontextprotocol/java-sdk#201 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
b9666ad to
d436271
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (1)
backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt (1)
71-74:⚠️ Potential issue | 🟠 MajorGuard pre-handle local map lookup from reflective failures.
Line 72can throw if reflective map loading breaks (SDK internals change), and this path is currently not protected, so request handling can fail before reaching fallback behavior.💡 Suggested hardening
- if (sessionId != null && !sessionsMap.containsKey(sessionId)) { - recoverSessionFromRedis(sessionId) - } + if (sessionId != null) { + val missingLocally = + runCatching { !sessionsMap.containsKey(sessionId) } + .onFailure { log.warn("Failed to access MCP local sessions map; skipping Redis recovery", it) } + .getOrDefault(false) + if (missingLocally) { + recoverSessionFromRedis(sessionId) + } + }#!/bin/bash set -euo pipefail FILE="$(fd "McpSessionRedisFilter.kt" --type f | head -1)" echo "Inspecting: $FILE" echo echo "=== Pre-handle block ===" sed -n '68,78p' "$FILE" echo echo "=== sessionsMap access context ===" rg -n 'sessionsMap\.containsKey\(|recoverSessionFromRedis\(' "$FILE" -C2Expected result: the pre-handle
containsKeycall is shown directly in the condition, without local error-guarding.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt` around lines 71 - 74, The containsKey check on sessionsMap in the pre-handle block can throw if reflective map loading breaks; wrap the sessionsMap.containsKey(sessionId) call in a protective try-catch (or safe helper) inside the pre-handle so any Throwable from the reflective lookup is caught and treated as “not present”, then call recoverSessionFromRedis(sessionId) as the fallback; update the logic around the existing sessionsMap / recoverSessionFromRedis usage so exceptions from containsKey do not propagate out of the pre-handle.
🧹 Nitpick comments (1)
backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt (1)
84-84: Make session selection deterministic in the test.Using
first()onConcurrentHashMapkeys can become order-dependent if extra sessions exist, which can make this test flaky.✅ Suggested tweak
- val sessionId = sessionsMap.keys().toList().first() + val sessionId = + sessionsMap.keys().singleOrNull() + ?: error("Expected exactly one MCP session in map, got ${sessionsMap.keys()}")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt` at line 84, The test McpRedisSessionRecoveryTest uses sessionsMap.keys().toList().first() which is non-deterministic; make selection deterministic by ordering the keys before picking one (e.g., use sorted()/minOrNull() on sessionsMap.keys()) so sessionId is consistently the same across runs and the test stops being flaky.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Duplicate comments:
In `@backend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.kt`:
- Around line 71-74: The containsKey check on sessionsMap in the pre-handle
block can throw if reflective map loading breaks; wrap the
sessionsMap.containsKey(sessionId) call in a protective try-catch (or safe
helper) inside the pre-handle so any Throwable from the reflective lookup is
caught and treated as “not present”, then call
recoverSessionFromRedis(sessionId) as the fallback; update the logic around the
existing sessionsMap / recoverSessionFromRedis usage so exceptions from
containsKey do not propagate out of the pre-handle.
---
Nitpick comments:
In `@backend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt`:
- Line 84: The test McpRedisSessionRecoveryTest uses
sessionsMap.keys().toList().first() which is non-deterministic; make selection
deterministic by ordering the keys before picking one (e.g., use
sorted()/minOrNull() on sessionsMap.keys()) so sessionId is consistently the
same across runs and the test stops being flaky.
ℹ️ Review info
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
backend/app/build.gradlebackend/app/src/main/kotlin/io/tolgee/mcp/McpConfig.ktbackend/app/src/main/kotlin/io/tolgee/mcp/McpSessionData.ktbackend/app/src/main/kotlin/io/tolgee/mcp/McpSessionRedisFilter.ktbackend/app/src/test/kotlin/io/tolgee/mcp/McpRedisSessionRecoveryTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
- backend/app/src/main/kotlin/io/tolgee/mcp/McpConfig.kt
## [3.163.3](v3.163.2...v3.163.3) (2026-02-26) ### Bug Fixes * sync MCP sessions to Redis for multi-replica deployments ([#3491](#3491)) ([3f25d44](3f25d44))
WebMvcStreamableServerTransportProvider stores sessions in an in-memory ConcurrentHashMap, causing 404 "Session not found" errors when requests scatter across replicas. Add a servlet filter that persists session metadata to Redis and reconstructs sessions on replicas that haven't seen the client.
Tracked upstream: modelcontextprotocol/java-sdk#201
Summary by CodeRabbit
New Features
Tests